logo
【Shelfie】DevLog #05 | Clerk から独自 OAuth プロバイダへ移行した話

【Shelfie】DevLog #05 | Clerk から独自 OAuth プロバイダへ移行した話

2026年08月29日

前回の記事ではShelfieの認証をClerkで実装した話を書きました。

その記事の最後に

本格的な認証基盤を実装してみました。(といってもSaaSに頼ってますが。。。)

と書いたのですが、今回はそのClerkを剥がして認証プロバイダをバックエンドに自前で構築した話をまとめていきたいと思います。

結果として、Google認証に加えてGitHub認証にも対応し、追加したgemはゼロ本になりました。

移行前と移行後で変わったのは以下です。

  • 入口の本人確認 : Clerk(フロント主導) → Rails(GET /auth/:provider)
  • 対応プロバイダ : Google → Google / GitHub
  • 使用gem : clerk-sdk-ruby + jwt → jwt のみ
  • ユーザー識別 : users.clerk_user_id → user_identitiesテーブル

逆に言うと、変わったのはこれだけです。

前回、セッション管理をバックエンドの責務にしておいたおかげで、トークンの発行処理も全APIの認証ロジックも無変更で移行できました。

当時の判断がここで効いてくるとは思っていませんでした。

本記事では

  • Clerkをやめた理由
  • 認証処理の候補①Auth.js
  • 認証処理の候補②独自プロバイダ
  • 独自プロバイダを採用した理由

をまとめます。

Clerkをやめた理由

正直に書くと、一度は**「ユーザーが自分1人しかいないうちはClerkのままリリースしよう」**と判断していました。

それを撤回したので、その理由から書いていきます。

理由1 SaaSが増えるほど管理が面倒になる

Shelfieでは認証以外にもいくつかSaaSを利用しています。

SaaSは便利なのですが、増えれば増えるほど

  • 管理画面が増える
  • 課金対象が増える
  • 障害が起きたときの切り分け先が増える

といったコストがじわじわ効いてきます。

そしてClerkが担っていたのは「入口の本人確認」の1点だけです。

前回独自実装したトークンの発行処理も、リフレッシュトークンのDB管理も、各APIの認証処理も、Clerkには一切依存していません。

1点のためだけに管理対象を1つ抱え続けるのは、割に合わないなと感じました。

理由2 ブランディングがプロダクトの目的を壊す

こちらのほうが決定的でした。

Clerkは無料プランだと、認証フローにブランディングが表示されます。

Shelfieは個人サイトのBooksページの飛び先をちゃんとした本物のサービスにすることが目的のプロダクトです。

その入口でアプリケーションがチープに見えてしまうのは、プロダクトの目的そのものを壊していることになります。

かといってこれを消すには本番インスタンスの構築(独自ドメインのDNS設定など)が必要で、Clerkを残す場合でも作業は発生します。

撤回した理由

この2つを並べたときに気づいたのは、

「作業はするのに、目的は達成されない」

という状態になっていることでした。

最初に「Clerkのまま出す」と判断したとき、私は移行する側のコストだけを見ていました。

ユーザーが1人なら移行の便益はゼロなので、コストを払う意味がない、という理屈です。

ただこれは片手落ちで、残す側にもコストがあることを勘定に入れていませんでした。

移行の便益がゼロでも、残す側にもコストがかかり、しかも目的が達成されないなら、残す理由はありません。

ここで方針を撤回して、Clerkを剥がすことにしました。

認証処理の候補①Auth.js

Clerkをやめると決めた次は、認証をどう実装するかです。

ここで最初に候補に挙がったのがAuth.js(旧NextAuth.js)でした。

Next.jsを使っているので一番素直な選択肢に見えました。

なお、以下の比較は移行を検討した2026年8月時点のものです。

認証まわりのライブラリは動きが速いので、いま同じ検討をするなら選択肢の顔ぶれ自体が変わっているかもしれません。

ただ、調べていくうちに争点が見えてきました。

争点は「フロントで実装するかバックエンドで実装するか」ではなく、

どちらのサーバーが認証を所有するか

でした。

Auth.jsはNext.jsのサーバー側で動きます。client_secretを保持し、認可コードの交換もNext.js側で行います。

つまりこの案を採ると、フロントエンドは「APIを叩くだけ」の存在ではなくなり、認証の当事者になります。

処理の流れ

sequenceDiagram
    participant U as ユーザー
    participant N as Next.js (Auth.js)
    participant G as Google
    participant R as Rails API

    U->>N: Googleでログイン押下
    N->>G: 認可要求
    G->>U: 認可画面表示
    U->>G: ログイン・許可

    G->>N: Authorization Code
    N->>G: Code + client_secret でトークン交換
    G->>N: id_token

    N->>R: id_token を送信
    R->>G: JWKS(公開鍵)を取得
    R->>R: 署名・iss・aud・exp を検証
    R->>N: Access Token / Refresh Token

メリット

  • 実装量が少ない
  • 設定ファイルベースなのでプロバイダの追加が楽
  • Next.jsで認証を扱う前提の情報・実装例が豊富

デメリット

client_secretの保管先が2箇所になる

Auth.jsがコード交換を行うので、Next.js側にclient_secretが必要です。

Rails側にも設定は残るため、同じ秘密情報を2箇所で管理することになります。

JWKS検証が必須になる

Railsが受け取るid_tokenは、フロントエンドという信頼できない経路から渡ってきた文字列です。

そのため、Rails側でGoogleの公開鍵(JWKS)を取得して署名を検証する必要があります。

ここが怖いところで、検証項目のうち**aud(このトークンは誰宛か)の検証を落とすと、他人のアプリ向けに発行された正規のトークンでなりすませてしまいます。**

署名自体は本物なので、署名検証だけでは気づけません。

つまりこの構成では、「フロントから送られてきた文字列を疑う」処理が認証の本体になります。

セッションの所有者が二重になる

Auth.js側にもセッションの概念があり、Rails側にもリフレッシュトークンによるセッション管理があります。

ログイン状態をどちらが管理しているのかが曖昧になり、ログアウトや強制ログアウトの実装で必ず悩むことになります。

そしてGitHubで詰みました

プロダクトの方針としてGoogleとGitHubの両方に対応すると決めた時点で、この案は選択できなくなりました。

理由はシンプルで、

GitHubはOIDCではないため、id_tokenを返さない

からです。

前回の記事で書いた通り、id_tokenは**OIDC(OpenID Connect)**というOAuth2を拡張した仕組みによって追加されたものです。

GitHubのOAuthはOIDCに対応していないので、id_tokenが存在しません。

Auth.js案の「Next.jsからRailsへid_tokenを渡す」という橋は、Googleのid_token1本で成り立っていました。

その橋はGitHubには架かりません。

迂回策も考えましたが、どちらもAuth.js案を選んだ理由を自分で壊してしまいます。

  • access_tokenをRailsに送ってGET /userを叩く
    → GitHubのaccess_tokenはどのOAuthアプリ向けに発行されたものか判別できません。 他のアプリのトークンを投げ込めばなりすましが成立します
  • POST /applications/{client_id}/tokenで照会する
    → Railsにもclient_secretが必要になり、結局同じ秘密が2箇所に複製されます

要件を1つ足しただけで設計が構造的に破綻する、というのは学びでした。

認証処理の候補②独自プロバイダ

もう一つの案が、Railsが認証を所有する構成です。

認可コードフロー + PKCE(S256)をバックエンドに自前で実装し、認可コードの交換もRailsが自分で行います。

フロントエンドは認証の当事者にならず、APIを叩くだけです。

なお、GitHubがPKCEに対応したのは2025年7月とかなり最近です。

受け付けるのはS256のみで、必須ではなく任意という扱いになっています。(GitHubはpublicクライアントとconfidentialクライアントを区別しないため)

処理の流れ

sequenceDiagram
    participant U as ブラウザ
    participant N as Next.js
    participant R as Rails API
    participant G as Google / GitHub

    U->>N: ログイン画面表示
    N->>U: {API}/auth/google へのリンク
    U->>R: GET /auth/google(トップレベル遷移)

    R->>R: state / code_verifier 生成
    R->>U: 302 認可URLへ + oauth_state Cookie

    U->>G: 認可画面
    G->>U: ログイン・許可
    G->>U: 302 /auth/google/callback?code=..&state=..

    U->>R: callback(Cookie 同送)
    R->>R: Cookie 削除 → state 照合
    R->>G: コード交換(client_secret + code + code_verifier)
    G->>R: Google:id_token / GitHub:access_token

    R->>R: 本人情報取得 → user_identities 検索
    R->>U: 302 フロントへ + refresh_token Cookie
    U->>R: POST /v1/auth/refresh
    R->>U: Access Token

入口となるGET /auth/:providerとGET /auth/:provider/callbackの2つは、ブラウザのトップレベル遷移で叩かれるのでJSON APIではありません。

そのため他のAPIとは別系統のコントローラーにしています。

メリット

client_secretの保管先がRailsだけになる

コード交換をRailsが行うので、フロント側に秘密情報を置く必要がありません。

JWKS検証が不要になる

ここが一番大きいポイントでした。

id_tokenをGoogleのトークンエンドポイントからTLS越しに直接受け取っているため、発行元の検証はTLSのサーバー検証で代替できます(OIDC Core 3.1.3.7)。

そして何より、Railsが自分でコード交換をしている以上、手に入るトークンは定義上このアプリのものです。

つまりAuth.js案で怖かった「aud検証を落とすとなりすまされる」というクラスの脆弱性が、構造的に存在しません。

もちろんiss / aud / exp / email_verifiedの4つはきちんと検証しています。

ただ、Auth.js案とは意味合いがまったく違います。

あちらは検証が唯一の防波堤なので、落とせばそのまま穴になります。

こちらは仮に落としたとしても、他のアプリ向けのトークンが混入してくる経路そのものがありません。 検証は多層防御の一枚目、という位置づけです。

検証を頑張って安全にするのではなく、検証が要らない構造にできる、というのが決め手になりました。

セッションの所有者がRailsだけになる

ログイン状態の管理がRailsに一本化されます。前回の設計をそのまま活かせました。

プロバイダ差が1メソッドに閉じる

GoogleとGitHubで実際に違うのは、本人情報の取り出し方だけでした。

  • 認可URLの組み立て : 共通(URLとscopeの定数だけ違う)
  • state / PKCE : 共通
  • コード交換 : 共通(URLの定数だけ違う)
  • 本人情報の取り出し : Googleはid_tokenのクレーム、GitHubはGET /user + GET /user/emails
  • ユーザー検索〜Cookie発行 : 共通

なので、プロバイダごとの差分はbuild_identityというメソッドの実装だけに閉じ込めました。

3つ目のプロバイダを足すときに触るのは、クラス1個とレジストリ1行だけです。

デメリット

  • 単純に実装量が増える
  • state / PKCE / Cookieの属性 / エラー時の分岐を自分で設計する必要がある

認証は間違えたときの被害が大きいので、ここは素直に怖い部分でした。

gem(OmniAuth)を使わなかった理由

「Railsで実装する」と決めた後、さらにOmniAuthを使うか自前で書くかという分岐がありました。

普通に考えれば実績のあるgemを使うべきですが、この構成ではOmniAuthの利点が発揮されないという結論になりました。

api_onlyを崩すことになる

OmniAuthのOAuth2ストラテジは、stateやcode_verifierの置き場にRailsのsessionを使います。

ShelfieのバックエンドはAPIモードなのでsession middlewareを積んでいません。

OmniAuthを使うなら、cookiesとsession middlewareを戻す必要があります。

これは「セッションの所有者を1つにする」という、独自プロバイダ案を選んだ判断軸を自分で崩すことになります。

自前で書けばstateとcode_verifierは10分だけ生きる署名付きCookieに載せられるので、サーバー側に状態を持たずに済みます。

CSRF対策gemとリンク遷移が両立しない

独自プロバイダ案では、ログインを<a href="{API}/auth/google">というただのリンク遷移(GET)で開始します。

ところがOmniAuth 2.0の既定はallowed_request_methods = [:post]で、GETでの開始が塞がれています。

加えてomniauth-rails_csrf_protectionを入れると、Railsのauthenticity tokenも要求されます。

APIモード + Next.js分離の構成でCSRFトークンを取得するには、セッションCookieとトークン発行用のエンドポイントが必要になります。

これは「フロントはAPIを叩くだけ」という前提を壊します。

かといって、CSRF対策のほうを自分で外して回避する、という判断もしたくありませんでした。

差分は約100行だった

そもそもコントローラーやコールバックのサービスクラス、user_identitiesテーブルまわりはOmniAuthを使っても自分で書く部分です。

見積もってみると、自前化による純増は約100行程度でした。

その100行と引き換えに、gemゼロ本・APIモードの維持・CVE対策を自分で無効化する判断の回避が得られるなら、自前で書く価値はあると判断しました。

4種類のトークンとpurposeクレーム

自前実装で一番気を使ったのがここなので、少し書いておきます。

Shelfieでは以下の4種類のJWTを発行しています。

  • oauth_state : 有効期限10分 / HttpOnly Cookie / 中身は provider・state・code_verifier
  • signup_token : 有効期限10分 / HttpOnly Cookie / 中身は provider・uid・email・name
  • refresh_token : 有効期限30日 / HttpOnly Cookie + DBに保存 / 中身は user_id
  • access_token : 有効期限60分 / フロントのメモリ(JS変数) / 中身は user_id

ここで問題になるのが、4種類すべてが同じ鍵で署名されていることです。

種別を書いておかないと相互に通用してしまいます。

特にまずいのが、有効期限30日のrefresh_tokenをAuthorization: Bearerに入れるとaccess_tokenとして通ってしまうケースです。

そのため、JWTにpurposeというクレームを持たせて種別を区別しています。

そしてデコードするメソッドはpurposeを必須のキーワード引数で受け取るようにしました。

TokenIssuer.decode(token, purpose: TokenIssuer::PURPOSE_ACCESS)

こうしておくと、呼び出す側が種別の検証をうっかり省略できません。

「気をつける」ではなく「書けない形にする」ほうが確実だと思ってこうしています。

ちなみにアクセストークンをlocalStorageではなくメモリに置いているのは、XSSでスクリプトから読み取られるのを避けるためです。

とはいえメモリに置けばXSSに対して安全、というわけではありません。

永続化されない分だけ被害の窓が狭くなる、という程度の話です。リロード時はリフレッシュトークンのCookieから取り直します。

独自プロバイダを採用

比較した結果、Shelfieでは独自プロバイダを採用しました。

改めて2案を並べると以下になります。

Auth.js 独自プロバイダ(採用)
client_secretの保管先 Next.js + Rails Railsのみ
Railsが受け取るもの id_token(信頼できない経路) TLSで直接受領
JWKS検証 必須 不要
セッションの所有者 Auth.jsとRailsで二重 Railsのみ
GitHub対応 構造的に不可 可
実装量 少ない 多い(差分は約100行)

決め手は3つでした。

決め手1 GitHub対応が要件だった

これは比較というより足切りです。

GitHubがOIDCではない以上、Auth.js案は成立しませんでした。

決め手2 攻撃面を持たないほうを選んだ

「検証を頑張って安全にする」よりも「検証が要らない構造にする」ほうが強いという判断です。

Auth.js案は、実装は少ないですがaudの検証を落とした瞬間になりすましが成立します。

自分ひとりで開発していて、レビューしてくれる人もいない状況では、うっかりが即座に脆弱性になる構造は避けたいと思いました。

決め手3 秘密とセッションの所有者を1つにできた

これは実はClerkをやめた理由と同じ話です。

Clerkをやめた動機のひとつは「管理対象を減らしたい」でした。

同じ基準で選ぶなら、client_secretもセッションもRailsに集約できる独自プロバイダを選ぶのが一貫しています。

最後に

認証はサービスの核なので、考えることは本当にたくさんありました。

  • トークンの種別をどう区別するか
  • Cookieの属性をどう設定するか
  • stateとPKCEをどこに置くか
  • コールバックが失敗する全経路をどう分岐させるか

ただ、やってみると基本さえ押さえていれば手が届く範囲でもありました。

前回OAuth2を一から学習していたことがそのまま効いていて、あのとき「いつかOIDCも学習したい」と書いて残していた宿題も、今回まとめて回収した形になります。

そしてもう一つ大きかったのがAIの活用です。

判断の分岐(Clerkを残すか / どちらのサーバーが所有するか / OmniAuthか自前か)は、すべてAIと壁打ちしながら詰めました。

一人だと「なんとなくこっちが良さそう」で進めてしまうところを、案を出し切らせて比較し、却下理由まで言語化できたのは大きな収穫でした。

その内容はADR(設計決定記録)としてリポジトリに残しています。

前回の記事で

といってもSaaSに頼ってますが。。。

と書いた部分に、今回ようやく回答を出せました。

最後までご愛読ありがとうございました。

shelfie認証OAuth

tofu

Tofu

Web Developer / Software Engineer